Skip to content

[XNNPACK] Lower upsample_nearest2d x2 as concatenate4 + depth_to_space_2d - #24113

Merged
JakeStevens merged 4 commits into
pytorch:mainfrom
borys3kk:xnnpack-upsample-nearest2d-main
Oct 30, 2026
Merged

JakeStevens merged 4 commits into
pytorch:mainfrom
borys3kk:xnnpack-upsample-nearest2d-main

Conversation

@borys3kk

@borys3kk borys3kk commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Profiling the MI-GAN inpainting model (a conv encoder-decoder, fp16) on XNNPACK showed the graph split into 7-8 delegate partitions around portable upsample_nearest2d ops. This PR lowers upsample_nearest2d x2.

XNNPACK has no nearest resize, and its static_broadcast is defined but returns xnn_status_unsupported_parameter at reshape time. Nearest x2 is instead lowered as concatenate4 of the input with itself along channels ([N, H, W, 4C]), followed by depth_to_space_2d(block_size=2). This adds an XNNDepthToSpace2d node, appended at the end of the XNodeUnion in schema.fbs / runtime_schema.fbs so existing node IDs don't change, plus defineDepthToSpace2dNode in XNNCompiler.cpp. UpsampleNearest2dConfig only partitions static 4D inputs with an exact x2 output. An explicit scale_factors that is not exactly 2.0 stays portable as well: PyTorch indexes with the given scale rather than the size ratio, so scale_factor=2.004 doubles the size but picks different pixels. Static quantization is supported: the quantizer annotates the op with input/output shared qparams, and the intermediate value carries them, since neither node changes values. When a graph quantizes the output differently from the input, depth_to_space_2d writes into an intermediate with the input's parameters and an XNNConvert requantizes it, because XNNPACK requires both to match. The x2 constraint lives in why_not_x2_nearest_upsample, shared by UpsampleNearest2dConfig and the annotator, so the quantizer only annotates what the partitioner lowers.

The ChannelsLastTaggedReshapePass change that was originally part of this PR (keeping NHWC across scalar and low-rank broadcast operands) moved to its own PR at review request, together with the fix for the dynamic-shape low-rank operand case reported below.

Measured on MI-GAN 256/512 fp16 with the host runtime, together with that pass change, on an ExecuTorch 1.4.1-based branch: XNNPACK partitions went from 7/8 to 1, the delegated share of run time from 87/82% to 98/95%, and Method::execute dropped by 29%/28%. The output was unchanged (max abs diff 2e-3, fp16 noise).

The new node needs an updated runtime. A program that uses it fails to load on an older runtime.

Review order: the serialization schema and XNNCompiler.cpp, then op_upsample_nearest2d.py with UpsampleNearest2dConfig and the quantizer annotator, then the tests.

Test plan

Tested on macOS arm64, with pybindings built from this branch (cmake --preset pybind, target portable_lib) so that the run-on-delegate stages use the new runtime node.

backends/xnnpack/test/ops/test_upsample_nearest2d.py covers x2 by scale and by size, fp16, fp32 and qs8 conv-upsample-conv lowered to one partition, a qs8 output quantized differently from the input, x3 and scale_factor=2.004 staying portable and not annotated, and scale_factors passed as a kwarg.

python -m pytest backends/xnnpack/test/ops/test_upsample_nearest2d.py backends/xnnpack/test/passes/test_channels_last_tagged_reshape.py backends/xnnpack/test/test_xnnpack_partitioner.py
# 38 passed

lintrunner reports no issues on the changed files.

…ow-rank broadcast operands

Profiling a conv encoder-decoder (MI-GAN inpainting, fp16) showed the graph
split into 7-8 XNNPACK partitions around portable upsample_nearest2d ops and
~25% of the time spent in NCHW<->NHWC transposes.

upsample_nearest2d.vec with an exact x2 scale on a static 4D input is now
partitioned and lowered as concatenate4 of the input along channels followed
by depth_to_space_2d(block_size=2). XNNPACK has no nearest resize and its
static_broadcast is not implemented at run time. This adds an
XNNDepthToSpace2d node to the flatbuffer schemas and XNNCompiler. Static
quantization is supported: the quantizer annotates the op with shared
input/output qparams and the intermediate value carries them. Other scales,
including an explicit scale_factors that is not exactly 2.0, stay portable.

In ChannelsLastTaggedReshapePass, single-element operands of
add/sub/mul/div/maximum/minimum (a lifted Python scalar such as the gain in
x * 1.4142) no longer take part in the layout decision, and rank < 4
operands that broadcast over the trailing dims (a [H, W] map added to
[N, C, H, W]) are reshaped to 4D with leading ones so they follow the NHWC
operand instead of dragging the activation back to NCHW. Dequantized
operands keep their rank, since a view between the dequantize and the op
would carry no quant params.
@pytorch-bot

pytorch-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24113

Note: Links to docs will display an error until the docs builds have been completed.

❗ 1 Active SEVs

There are 1 currently active SEVs. If your PR is affected, please view them below:

❌ 2 New Failures

As of commit b2eb31c with merge base 422cc39 (image):

NEW FAILURES - The following jobs have failed:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 23, 2026
@borys3kk

Copy link
Copy Markdown
Contributor Author

@pytorchbot label "release notes: xnnpack"

@borys3kk
borys3kk marked this pull request as ready for review October 26, 2026 14:45
@borys3kk
borys3kk requested a review from digantdesai as a code owner October 26, 2026 14:45
@pytorch-bot pytorch-bot Bot added the release notes: xnnpack Changes to the XNNPack backend delegate label Oct 26, 2026
@executorch-triage executorch-triage Bot added the community: contribution PRs coming from community (excluding hardware partners) label Oct 26, 2026
@nil-is-all nil-is-all added the module: xnnpack Issues related to xnnpack delegation and the code under backends/xnnpack/ label Oct 26, 2026
@JakeStevens

Copy link
Copy Markdown
Contributor

sorry, can we split this into two PRs? to keep the changes atomic per pr instead of squishing them together. the upsample changes are additive and lower risk.

that being said, a lower-rank operand with symbolic dims breaks lowering with these changes. Before, a dynamic-shape model with conv_out + y, where y is [H, W] or [C, H, W] with dynamic H/W, now fails in preprocess. On main it lowered fine by falling back to NCHW.

@nil-is-all nil-is-all added the need-user-input The issue needs more information from the reporter before moving forward label Oct 26, 2026
@borys3kk

Copy link
Copy Markdown
Contributor Author

sorry, can we split this into two PRs? to keep the changes atomic per pr instead of squishing them together. the upsample changes are additive and lower risk.

that being said, a lower-rank operand with symbolic dims breaks lowering with these changes. Before, a dynamic-shape model with conv_out + y, where y is [H, W] or [C, H, W] with dynamic H/W, now fails in preprocess. On main it lowered fine by falling back to NCHW.

I will look into that tomorrow

borys3kk added a commit to borys3kk/executorch that referenced this pull request Oct 27, 2026
@borys3kk borys3kk changed the title [XNNPACK] Lower upsample_nearest2d x2 and keep NHWC across scalar / low-rank broadcast operands [XNNPACK] Lower upsample_nearest2d x2 as concatenate4 + depth_to_space_2d Oct 27, 2026
@borys3kk

Copy link
Copy Markdown
Contributor Author

Done, split in two: this PR now only lowers upsample_nearest2d, the ChannelsLastTaggedReshapePass change moved to #24183.

Thanks for the dynamic-shape report, reproduced it: unsqueeze_to_4d put the symbolic dims into the view_copy shape, which XNNStaticReshape rejects. In #24183 an operand with symbolic dims keeps the NCHW path as on main, with a test for conv_out + y with y of shape [H, W] and dynamic H/W that also runs at a second input size.

@nil-is-all nil-is-all removed the need-user-input The issue needs more information from the reporter before moving forward label Oct 27, 2026
@JakeStevens

Copy link
Copy Markdown
Contributor

a few things:

  1. A quantized model can fail to load. xnn_define_depth_to_space_2d rejects the node unless its input and output have the same quantization parameters. The visitor uses the input's parameters for the input and separate output parameters for the output. The partitioner should check that the two match, or set up the quantization so they always do.
  2. Wrong results when scale_factors is a kwarg as the check that the scale is exactly 2.0 only reads node.args[2]. Take a 200×200 input with scale_factors=[2.004, 2.004] passed as a kwarg: the output is still 400×400, so the size check passes and the scale check is skipped.
  3. The qs8 test can't fail (test_upsample_nearest2d.py:61). check_not(["torch.ops.quantized_decomposed"]) runs on the edge graph, where the q/dq ops have different names, so it never matches.
  4. The annotator covers more than the partitioner accepts so it quantizes every upsample_nearest2d.vec

…lowered case

depth_to_space_2d requires its output to carry the input's quantization
parameters. When the output is quantized differently, the visitor now
writes depth_to_space into an intermediate with the input's parameters
and requantizes it with a convert, so such a graph loads and runs
instead of failing at load.

The x2 constraint is shared between the partitioner and the quantizer
through why_not_x2_nearest_upsample, which also reads scale_factors from
kwargs. The annotator skips every upsample_nearest2d the partitioner
would not lower, so a non-x2 upsample stays a float op without q/dq
around it.

The qs8 test asserted on a quantized_decomposed name that does not exist
in the edge graph; it now checks quantization on the exported graph and
relies on the single-partition check after lowering.
@borys3kk

Copy link
Copy Markdown
Contributor Author

Thanks, all four addressed in b2eb31c.

  1. Mismatched quantization parameters: handled in the visitor rather than rejected. When the output is quantized differently from the input, depth_to_space_2d writes into an intermediate carrying the input's parameters and an XNNConvert requantizes it to the output (XNNPACK supports qint8 to qint8 convert). I first tried rejecting the node in the partitioner, but since the surrounding q/dq get delegated on their own, the not-decompose sanity check in to_edge_transform_and_lower re-runs the constraint on the partitioned graph, where the q/dq context is gone, and raises. Test: test_qs8_upsample_nearest2d_requantized_output, which fails at load without the convert.
  2. scale_factors as a kwarg: the check reads node.kwargs when the positional argument is absent. torch.export and to_edge normalize it to positional, so I could not reproduce it end to end; the test rewrites the exported node to a kwarg and asserts on check_constraints directly.
  3. The qs8 test: the check_not on the edge graph was indeed vacuous, so it is gone. The test now checks on the exported graph that the model is quantized and relies on the single-delegate and no-edge-op checks after lowering. test_add.py and test_cat.py carry the same vacuous check_not; left untouched here.
  4. Annotator scope: the x2 constraint moved to why_not_x2_nearest_upsample in utils.py, used by both UpsampleNearest2dConfig and the annotator, so the quantizer only annotates what the partitioner lowers. Test: test_qs8_upsample_nearest2d_unsupported_scale_not_annotated for x3 and 2.004.

JakeStevens pushed a commit that referenced this pull request Oct 29, 2026
### Summary

Found while profiling the fp16 MI-GAN inpainting model. A zero
`constant_pad_nd` before a convolution costs a full extra pass over the
activation, which is noticeably slow in NHWC. In MI-GAN these pads, one
before every 4x4 blur filter, took 13-22% of the forward.

`FusePadIntoConvPass` folds a zero-valued `constant_pad_nd` on the two
spatial dims whose only user is a non-transposed convolution into that
convolution's padding. XNNPACK takes top/right/bottom/left padding
independently, so asymmetric pads such as `F.pad(x, (2, 1, 2, 1))`
before a 4x4 filter fold too. The graph and its shape metadata stay as
they are: the pass only tags nodes. The pad visitor aliases the pad
output to its input, and the conv visitor serializes the combined
padding.

The pass runs last, after `ChannelsLastTaggedReshapePass` and
`RemoveRedundantCopyPass`. If a layout copy sits between the pad and the
conv, the conv is no longer the pad's only user and nothing folds.
Quantized pads are left alone. `InsertPadQDQPass` runs earlier and puts
q/dq around pads fed by a dequantize, and the fold skips any pad whose
input is a dequantize or whose user is a quantize. A pad in a quantized
context therefore still becomes a quantized static pad, as before.
Non-zero pad values, transposed convolutions and pads with more than one
user are also not folded.

No runtime change is needed: TRBL conv padding already exists in the
runtime.

The fp32 <-> fp16 cast lowering that was originally part of this PR now
lives in #24277, as asked in review.

Results measured on an ExecuTorch 1.4.1-based branch, with the pad fold
and the cast lowering from #24277 together. They include the
`upsample_nearest2d` lowering from #24113, without which a layout copy
sits between every MI-GAN pad and its conv and nothing folds. Timings
are `Method::execute` for MI-GAN 256 / 512 / faces_256 fp16, 10 timed
runs after 2 warm-ups, output bit-identical:

| | 256 | 512 | faces_256 |
| --- | --- | --- | --- |
| Mac M4 Pro, 14 threads | 34 -> 27 ms (-21%) | 58 -> 41 ms (-30%) | 33
-> 27 ms (-17%) |
| Galaxy S23 (SD 8 Gen 2), 4 threads | 108 -> 105 ms (-3%) | 189 -> 182
ms (-3%) | 109 -> 107 ms (-2%) |

The host gain is larger because XNNPACK's `Constant Pad` is unusually
slow on the M4 Pro. On the device, the casts alone are within noise, so
nearly all of the difference is the fold.

Review order: `fuse_pad_into_conv_pass.py`, then
`op_static_constant_pad.py` and `op_conv2d.py`, then the tests.

### Test plan

`backends/xnnpack/test/passes/test_fuse_pad_into_conv_pass.py`:
conv-pad-conv in fp32 and fp16, with a depthwise conv, with a width-only
two-element pad, with a dilated conv and with dynamic height and width.
Each asserts through the pass stage that the pad is tagged as folded and
that the conv has the expected combined TRBL padding (existing conv
padding included), then lowers, runs and compares outputs. Pads that
must stay unfused (non-zero value, transposed conv, pad with two users,
pad on the graph input, qs8 graph with `InsertPadQDQPass` in the
pipeline) are checked the same way and must stay correct.

```bash
python -m pytest backends/xnnpack/test/passes/test_fuse_pad_into_conv_pass.py
# 11 passed

python -m pytest backends/xnnpack/test/passes/test_fuse_pad_into_conv_pass.py \
  backends/xnnpack/test/ops/test_conv2d.py \
  backends/xnnpack/test/ops/test_static_constant_pad.py \
  backends/xnnpack/test/passes/test_insert_pad_qdq.py
# 58 passed
```

Run on macOS (Apple Silicon, Python 3.13) against the prebuilt
pybindings. `lintrunner` on the changed files reports no issues.

cc @nil-is-all @GregoryComer @digantdesai @cbilgin @JakeStevens

@JakeStevens JakeStevens left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks!

@JakeStevens
JakeStevens merged commit 0f008d0 into pytorch:main Oct 30, 2026
213 of 215 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. community: contribution PRs coming from community (excluding hardware partners) module: xnnpack Issues related to xnnpack delegation and the code under backends/xnnpack/ release notes: xnnpack Changes to the XNNPack backend delegate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants